package org.luxor.sdk.common;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.google.gson.LongSerializationPolicy;
import com.google.gson.reflect.TypeToken;
import okhttp3.Credentials;
import okhttp3.Headers;
import okhttp3.Response;
import org.luxor.sdk.auth.v1.AccessToken;
import org.luxor.sdk.common.exception.LuxorCloudSDKException;
import org.luxor.sdk.common.http.HttpConnection;
import org.luxor.sdk.common.interceptor.RetryInterceptor;
import org.luxor.sdk.common.interceptor.TCLogInterceptor;
import org.luxor.sdk.common.models.*;
import org.luxor.sdk.common.profile.ClientProfile;
import org.luxor.sdk.common.profile.HttpMethod;

import javax.crypto.Mac;
import javax.net.ssl.SSLContext;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;

/**
 * @author Mr.yan  @date 2021/9/9
 */
public abstract class AbstractClient {
    protected AccessToken accessToken;

    public static final int HTTP_RSP_OK = 200;
    public static final String SDK_VERSION = "SDK_JAVA_1.0.0-SNAPSHOT";
    public static final String SKY_JSON_PAYLOAD = "{}";
    public static final String REQUEST_ID = "request_id";

    private final Credential credential;
    private final ClientProfile profile;
    private final String region;
    private final String path;
    private final String sdkVersion;
    private final String apiVersion;
    private final TCLogInterceptor log;
    private final RetryInterceptor retry;
    public final Gson gson;

    public AbstractClient(String version, Credential credential, String region, ClientProfile profile) {
        this.credential = credential;
        this.profile = profile;
        this.region = region;
        this.path = "/";
        this.sdkVersion = AbstractClient.SDK_VERSION;
        this.apiVersion = version;
        this.log = new TCLogInterceptor(getClass().getName(), profile.isDebug());
        this.retry = new RetryInterceptor(this.log, credential);
        this.gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation()
                //序列化日期格式  "yyyy-MM-dd"
                .setPrettyPrinting()
                // 解决long类型精度丢失问题
                .setLongSerializationPolicy(LongSerializationPolicy.STRING)
                // 指定返回的时间格式
                .setDateFormat("yyyy-MM-dd HH:mm:ss")
                // 不解析@Expose修饰的字段
                .excludeFieldsWithoutExposeAnnotation()
                // 禁止转义html标签
                .disableHtmlEscaping().create();
        warmup();
    }

    /**
     * @return ClientProfile
     */
    public ClientProfile getClientProfile() {
        return profile;
    }

    /**
     * @return Credential
     */
    public Credential getCredential() {
        return credential;
    }

    /**
     * Use get/json with hmac-sha256 signature to call any action. Ignore request method and
     * signature method defined in profile.
     *
     * @param actionEndpoint Name of action to be called.
     * @param params         Parameters of action canonical query string format.
     * @return Raw response from API if request succeeded, otherwise an exception will be raised
     * instead of raw response
     */
    public String getRequest(String actionEndpoint, HashMap<String, String> params) throws LuxorCloudSDKException {
        if (params == null) {
            params = new HashMap<>();
        }
        if (params.get(REQUEST_ID) == null) {
            params.put(REQUEST_ID, UUID.randomUUID().toString());
        }
        HashMap<String, String> headers = this.getHeaders(params.get(REQUEST_ID));
        headers.put("Content-Type", "application/json; charset=utf-8");
        AccessToken accessToken = this.getAuthorization(headers, "".getBytes(StandardCharsets.UTF_8));
        headers.put("Authorization", accessToken.getAuthorization());
        String url = this.profile.getHttpProfile().getHttpProtocol() + this.profile.getHttpProfile().getDomain() + actionEndpoint + this.path;
        String canonicalQueryString = formatQueryParams(params, null);
        return this.getResponseBody(HttpMethod.GET, url + "?" + canonicalQueryString, headers, null);
    }

    /**
     * Use post/json with hmac-sha256 signature to call any action. Ignore request method and
     * signature method defined in profile.
     *
     * @param actionEndpoint Name of action to be called.
     * @param params         Parameters of action canonical query string format.
     * @param jsonPayload    Parameters of action serialized in json string format.
     * @return Raw response from API if request succeeded, otherwise an exception will be raised
     * instead of raw response
     */
    public String postRequest(String actionEndpoint, HashMap<String, String> params, String jsonPayload) throws LuxorCloudSDKException {
        if (params == null) {
            params = new HashMap<>();
        }
        if (jsonPayload == null) {
            jsonPayload = SKY_JSON_PAYLOAD;
        }

        if (params.get(REQUEST_ID) == null) {
            params.put(REQUEST_ID, UUID.randomUUID().toString());
        }
        HashMap<String, String> headers = this.getHeaders(params.get(REQUEST_ID));
        headers.put("Content-Type", "application/json; charset=utf-8");
        byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8);
        AccessToken accessToken = this.getAuthorization(headers, requestPayload);
        headers.put("Authorization", accessToken.getAuthorization());
        String url = this.profile.getHttpProfile().getHttpProtocol() + this.profile.getHttpProfile().getDomain() + actionEndpoint + this.path;
        String canonicalQueryString = formatQueryParams(params, requestPayload);
        return this.getResponseBody(HttpMethod.POST, url + "?" + canonicalQueryString, headers, requestPayload);
    }

    /**
     * Use multipart/form-data with hmac-sha256 signature to call any action. Ignore request method and
     * signature method defined in profile.
     *
     * @param actionEndpoint  Name of action to be called.
     * @param params          Parameters of action canonical query string format.
     * @param multipartEntity binaryParams of action serialized in binary format.
     * @return Raw response from API if request succeeded, otherwise an exception will be raised
     * instead of raw response
     */
    public String postRequestWithFormData(String actionEndpoint, HashMap<String, String> params, MultipartEntity multipartEntity) throws LuxorCloudSDKException, IOException {
        if (params == null) {
            params = new HashMap<>();
        }
        if (params.get(REQUEST_ID) == null) {
            params.put(REQUEST_ID, UUID.randomUUID().toString());
        }
        HashMap<String, String> headers = this.getHeaders(params.get(REQUEST_ID));
        headers.put("Content-Type", "multipart/form-data; charset=utf-8");
        byte[] requestPayload = getMultipartPayload(multipartEntity, UUID.randomUUID().toString());
        AccessToken accessToken = this.getAuthorization(headers, requestPayload);
        headers.put("Authorization", accessToken.getAuthorization());
        String url = this.profile.getHttpProfile().getUrl() + actionEndpoint + this.path;
        String canonicalQueryString = formatQueryParams(params, requestPayload);
        return this.getResponseBody(HttpMethod.POST, url + "?" + canonicalQueryString, headers, requestPayload);
    }

    /**
     * Use application/octet-stream with hmac-sha256 signature to call any action. Ignore request method and
     * signature method defined in profile.
     *
     * @param actionEndpoint Name of action to be called.
     * @param params         Parameters of action canonical query string format.
     * @param bytesPayload   binaryParams of action serialized in binary format.
     * @return Raw response from API if request succeeded, otherwise an exception will be raised
     * instead of raw response
     */
    public String postRequestWithOctetStream(String actionEndpoint, HashMap<String, String> params, byte[] bytesPayload) throws LuxorCloudSDKException, IOException {
        if (params == null) {
            params = new HashMap<>();
        }
        if (params.get(REQUEST_ID) == null) {
            params.put(REQUEST_ID, UUID.randomUUID().toString());
        }
        HashMap<String, String> headers = this.getHeaders(params.get(REQUEST_ID));
        headers.put("Content-Type", "application/octet-stream; charset=utf-8");
        AccessToken accessToken = this.getAuthorization(headers, bytesPayload);
        headers.put("Authorization", accessToken.getAuthorization());
        String url = this.profile.getHttpProfile().getUrl() + actionEndpoint + this.path;
        String canonicalQueryString = formatQueryParams(params, bytesPayload);
        return this.getResponseBody(HttpMethod.POST, url + "?" + canonicalQueryString, headers, bytesPayload);
    }

    /**
     * Use put/json with hmac-sha256 signature to call any action. Ignore request method and
     * signature method defined in profile.
     *
     * @param actionEndpoint Name of action to be called.
     * @param params         Parameters of action canonical query string format.
     * @param jsonPayload    Parameters of action serialized in json string format.
     * @return Raw response from API if request succeeded, otherwise an exception will be raised
     * instead of raw response
     */
    public String putRequest(String actionEndpoint, HashMap<String, String> params, String jsonPayload) throws LuxorCloudSDKException {
        if (params == null) {
            params = new HashMap<>();
        }
        if (jsonPayload == null) {
            jsonPayload = SKY_JSON_PAYLOAD;
        }
        if (params.get(REQUEST_ID) == null) {
            params.put(REQUEST_ID, UUID.randomUUID().toString());
        }
        HashMap<String, String> headers = this.getHeaders(params.get(REQUEST_ID));
        headers.put("Content-Type", "application/json; charset=utf-8");
        byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8);
        AccessToken accessToken = this.getAuthorization(headers, requestPayload);
        headers.put("Authorization", accessToken.getAuthorization());
        String url = this.profile.getHttpProfile().getUrl() + actionEndpoint + this.path;
        String canonicalQueryString = formatQueryParams(params, requestPayload);
        return this.getResponseBody(HttpMethod.PUT, url + "?" + canonicalQueryString, headers, requestPayload);
    }

    /**
     * Use patch/json with hmac-sha256 signature to call any action. Ignore request method and
     * signature method defined in profile.
     *
     * @param actionEndpoint Name of action to be called.
     * @param params         Parameters of action canonical query string format.
     * @param jsonPayload    Parameters of action serialized in json string format.
     * @return Raw response from API if request succeeded, otherwise an exception will be raised
     * instead of raw response
     */
    public String patchRequest(String actionEndpoint, HashMap<String, String> params, String jsonPayload) throws LuxorCloudSDKException {
        if (params == null) {
            params = new HashMap<>();
        }
        if (jsonPayload == null) {
            jsonPayload = SKY_JSON_PAYLOAD;
        }
        if (params.get(REQUEST_ID) == null) {
            params.put(REQUEST_ID, UUID.randomUUID().toString());
        }
        HashMap<String, String> headers = this.getHeaders(params.get(REQUEST_ID));
        headers.put("Content-Type", "application/json; charset=utf-8");
        byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8);
        AccessToken accessToken = this.getAuthorization(headers, requestPayload);
        headers.put("Authorization", accessToken.getAuthorization());
        String url = this.profile.getHttpProfile().getUrl() + actionEndpoint + this.path;
        String canonicalQueryString = formatQueryParams(params, requestPayload);
        return this.getResponseBody(HttpMethod.PATCH, url + "?" + canonicalQueryString, headers, requestPayload);
    }


    /**
     * Use get/json with hmac-sha256 signature to call any action. Ignore request method and
     * signature method defined in profile.
     *
     * @param actionEndpoint Name of action to be called.
     * @param params         Parameters of action canonical query string format.
     * @param jsonPayload    Parameters of action serialized in json string format.
     * @return Raw response from API if request succeeded, otherwise an exception will be raised
     * instead of raw response
     */
    public String deleteRequest(String actionEndpoint, HashMap<String, String> params, String jsonPayload) throws LuxorCloudSDKException {
        if (params == null) {
            params = new HashMap<>();
        }
        if (jsonPayload == null) {
            jsonPayload = SKY_JSON_PAYLOAD;
        }
        if (params.get(REQUEST_ID) == null) {
            params.put(REQUEST_ID, UUID.randomUUID().toString());
        }
        HashMap<String, String> headers = this.getHeaders(params.get(REQUEST_ID));
        headers.put("Content-Type", "application/json; charset=utf-8");
        byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8);
        AccessToken accessToken = this.getAuthorization(headers, requestPayload);
        headers.put("Authorization", accessToken.getAuthorization());
        String url = this.profile.getHttpProfile().getUrl() + actionEndpoint + this.path;
        String canonicalQueryString = formatQueryParams(params, requestPayload);
        return this.getResponseBody(HttpMethod.DELETE, url + "?" + canonicalQueryString, headers, requestPayload);
    }


    private HashMap<String, String> getHeaders(String requestId) {
        HashMap<String, String> headers = new HashMap<>();
        headers.put("X-RequestId", requestId);
        headers.put("X-Version", this.apiVersion);
        headers.put("X-Region", this.region);
        headers.put("X-RequestClient", this.sdkVersion);
        headers.put("Host", this.profile.getHttpProfile().getDomain());
        return headers;
    }


    /**
     * Get authorization.
     *
     * @param headers     Parameters of action canonical headers string format.
     * @param jsonPayload Parameters of action serialized in bytes format.
     * @return AccessToken
     */
    protected abstract AccessToken getAuthorization(HashMap<String, String> headers, byte[] jsonPayload) throws LuxorCloudSDKException;

    /**
     * Call any action.
     *
     * @param httpMethod http method
     * @param url        Parameters of url.
     * @param headers    Parameters of action canonical headers string format.
     * @param body       Parameters of action serialized in bytes format.
     * @return Response of json string format.
     */
    protected String getResponseBody(HttpMethod httpMethod, String url, HashMap<String, String> headers, byte[] body) throws LuxorCloudSDKException {
        HttpConnection conn = new HttpConnection(this.profile.getHttpProfile().getConnTimeout(), this.profile.getHttpProfile().getReadTimeout(), this.profile.getHttpProfile().getWriteTimeout());
        conn.addInterceptors(log);
        conn.addInterceptors(retry);
        this.trySetProxy(conn);
        Headers.Builder hb = new Headers.Builder();
        for (String key : headers.keySet()) {
            hb.add(key, headers.get(key));
        }
        Response resp;
        if (httpMethod.equals(HttpMethod.GET)) {
            resp = conn.getRequest(url, hb.build());
        } else if (httpMethod.equals(HttpMethod.POST)) {
            resp = conn.postRequest(url, body, hb.build());
        } else if (httpMethod.equals(HttpMethod.PATCH)) {
            resp = conn.patchRequest(url, body, hb.build());
        } else if (httpMethod.equals(HttpMethod.PUT)) {
            resp = conn.putRequest(url, body, hb.build());
        } else if (httpMethod.equals(HttpMethod.DELETE)) {
            resp = conn.deleteRequest(url, body, hb.build());
        } else {
            throw new LuxorCloudSDKException("Method only support (GET, POST, PATCH, PUT, DELETE)", headers.get("X-RequestId"));
        }
        if (resp.code() != AbstractClient.HTTP_RSP_OK) {
            String msg = "response code is " + resp.code() + ", not 200";
            log.info(msg);
            throw new LuxorCloudSDKException(msg, headers.get("X-RequestId"), "ServerSideError");
        }
        String respBody = null;
        try {
            if (resp.body() != null) {
                respBody = resp.body().string();
            }
        } catch (IOException e) {
            String msg = "Cannot transfer response body to string, because Content-Length is too large" + ", or Content-Length and stream length disagree.";
            log.info(msg);
            throw new LuxorCloudSDKException(msg, headers.get("X-RequestId"), e.getClass().getName());
        }
        ResponseModel<Object> errResp;
        try {
            Type errType = new TypeToken<ResponseModel<Object>>() {
            }.getType();
            errResp = gson.fromJson(respBody, errType);
        } catch (JsonSyntaxException e) {
            String msg = "json is not a valid representation for an object of type";
            log.info(msg);
            throw new LuxorCloudSDKException(msg, "", e.getClass().getName());
        }
        if (errResp != null && 0 != errResp.code) {
            throw new LuxorCloudSDKException(errResp.message, headers.get("X-RequestId"), String.valueOf(errResp.code));
        }
        return respBody;
    }

    private void trySetProxy(HttpConnection conn) {
        if (this.profile.getHttpProfile().getProxy() == null) {
            return;
        }
        String host = this.profile.getHttpProfile().getProxy().getHost();
        int port = this.profile.getHttpProfile().getProxy().getPort();
        if (host == null || host.isEmpty()) {
            return;
        }
        Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port));
        conn.setProxy(proxy);

        final String username = this.profile.getHttpProfile().getProxy().getUsername();
        final String password = this.profile.getHttpProfile().getProxy().getPassword();
        if (username == null || username.isEmpty()) {
            return;
        }
        conn.setProxyAuthenticator((route, response) -> {
            String credential = Credentials.basic(username, password);
            return response.request().newBuilder().header("Proxy-Authorization", credential).build();
        });
    }

    private byte[] getMultipartPayload(MultipartEntity multipartEntity, String boundary) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        Map<String, Part<?>> params = multipartEntity.getMultipartRequestParams();
        for (Map.Entry<String, Part<?>> entry : params.entrySet()) {
            baos.write("--".getBytes(StandardCharsets.UTF_8));
            baos.write(boundary.getBytes(StandardCharsets.UTF_8));
            baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
            baos.write("Content-Disposition: form-data; name=\"".getBytes(StandardCharsets.UTF_8));
            baos.write(entry.getKey().getBytes(StandardCharsets.UTF_8));
            if (entry.getValue() instanceof BinaryPart) {
                BinaryPart binaryPart = ((BinaryPart) entry.getValue());
                baos.write("\"; filename=\"".getBytes(StandardCharsets.UTF_8));
                baos.write(binaryPart.getFileName().getBytes(StandardCharsets.UTF_8));
                baos.write("\"\r\n".getBytes(StandardCharsets.UTF_8));
                baos.write("Content-Type: ".getBytes(StandardCharsets.UTF_8));
                baos.write(binaryPart.getContentType().getBytes(StandardCharsets.UTF_8));
                baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
                baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
                baos.write(binaryPart.getValue());
            } else {
                StringPart stringPart = ((StringPart) entry.getValue());
                baos.write("\"\r\n".getBytes(StandardCharsets.UTF_8));
                baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
                baos.write(stringPart.getValue().getBytes(StandardCharsets.UTF_8));
            }
            baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
        }
        if (baos.size() != 0) {
            baos.write("--".getBytes(StandardCharsets.UTF_8));
            baos.write(boundary.getBytes(StandardCharsets.UTF_8));
            baos.write("--\r\n".getBytes(StandardCharsets.UTF_8));
        }
        byte[] bytes = baos.toByteArray();
        baos.close();
        return bytes;
    }

    protected String formatQueryParams(HashMap<String, String> params, byte[] requestPayload) throws LuxorCloudSDKException {
        if (params == null) {
            params = new HashMap<>();
        }
        try {
            params.put(Sign.CLIENT_ID, getCredential().getClientId());
            params.put(Sign.NONCE, String.valueOf(Math.abs(new SecureRandom().nextInt())));
            params.put(Sign.TIMESTAMP, String.valueOf(System.currentTimeMillis() / 1000));
            String signPlainText = Sign.makeSignPlainText(getCredential().getClientSecret(), new TreeMap<>(params), requestPayload);
            String signature = Sign.sign(getCredential().getClientSecret(), signPlainText, Sign.SIG_METHOD);
            params.put(Sign.SIGNATURE, signature);

            StringBuilder queryString = new StringBuilder();
            for (Map.Entry<String, String> entry : params.entrySet()) {
                String v;
                v = URLEncoder.encode(entry.getValue(), "UTF8");
                queryString.append("&").append(entry.getKey()).append("=").append(v);
            }
            if (queryString.length() == 0) {
                return "";
            } else {
                return queryString.substring(1);
            }
        } catch (Exception e) {
            throw new LuxorCloudSDKException(e.getMessage());
        }

    }

    /**
     * 预热，尽量避免在第一个请求中产生不必要的开销
     */
    private void warmup() {
        try {
            // it happens in SDK signature process.
            // first invoke costs around 250 ms.
            Mac.getInstance("HmacSHA1");
            Mac.getInstance("HmacSHA256");
            // it happens inside okhttp, but I think any https framework/package will do the same.
            // first invoke costs around 150 ms.
            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, null, null);
        } catch (Exception e) {
            // ignore but print message to console
            e.printStackTrace();
        }
    }
}
